Transactions Table at Scale β€” Worked Example (RADIO)

Plaid-style front-end round Β· 10k–100k rows Β· live balances and an updating feed Β· a teaching walkthrough, not just a cheatsheet. RADIO: Requirements β†’ Architecture β†’ Data model β†’ Interface β†’ Optimizations.

1. R β€” Requirements

Start by restating the problem in your own words, because the restatement is where you earn the right to make assumptions later. Something like: "We're building the transactions view for a consumer fintech app. A user has linked several bank accounts through something like Plaid, and we need to show them every transaction across those accounts β€” which can be tens of thousands of rows β€” with filtering, search, and sorting, and the view should feel live: new transactions and balance changes appear without a manual refresh."

The functional scope worth committing to out loud: viewing transactions across multiple accounts, filtering by account, date range, category, and amount, free-text search, sorting, a row detail view, and bulk selection for export or re-categorization. Just as important is what you cut. Say explicitly that money movement, the categorization ML itself, and admin tooling are out of scope. Interviewers read an unstated scope as an unmanaged one, and the recruiter notes say they're watching for planning ahead.

The non-functional requirements are what actually shape this design, and there are three that matter. First, scale of rendering: 100k rows cannot go in the DOM. It's not a JavaScript problem, it's a layout-and-paint problem, and it forces virtualization no matter what else we choose. Second, freshness with honesty: financial data is eventually consistent by nature. A transaction appears as pending, then posts days later with possibly a different amount; a balance reflects a sync that happened minutes ago. Pretending this data is real-time and strongly consistent is the classic fintech red flag β€” the design has to surface as-of timestamps and treat pending-versus-posted as a first-class state, not an edge case. Third, sensitivity: this screen is nothing but financial PII. That constrains where tokens live, what gets logged to analytics, and what's masked on screen.

The core tension to name before drawing anything: we have a huge, constantly-changing server-side dataset behind a client that can only afford to hold and render a tiny window of it. Every decision that follows β€” server-side filtering, cursor pagination, virtualization, cache patching β€” is a consequence of that one sentence. Saying it up front tells the interviewer you see the shape of the problem, not just its parts.

2. A β€” Architecture

Component tree TransactionsPage FilterBar BalanceHeader VirtualizedTable renders only visible window (~30 rows) Row (memoized, fixed height) Row … RowDetailDrawer (lazy) State β€” 3 layers, separated URL state ?account=&category= &sort=date&q=… Server cache React Query / SWR pages keyed by (filters, sort, cursor) Local UI state selection set, expanded row, raw (undebounced) search text, scroll offset BFF / API (given boilerplate) GET /transactions?cursor=… server-side filter/sort/search short-lived scoped token (HttpOnly cookie, no localStorage) Real-time channel SSE: tx.created / tx.updated / balance.changed (+ cursor) fallback: 30s poll filtersβ†’URL rows fetch page invalidate/patch cache Virtualization: DOM = window + overscan, spacers fake the rest top spacer (height = skipped rows Γ— 44px) ~20 visible + 10 overscan rows in DOM absolutely positioned, translateY(index Γ— 44) bottom spacer sentinel row β†’ fetch next page

The architecture has three ideas in it, and each one deserves a sentence of defense rather than just a box on the diagram.

Idea one: state is split into three layers with different owners, because the three kinds of state have different lifetimes and different consumers. Filters, sort, and the search query go in the URL. This is not a stylistic preference β€” those values determine what data the user is looking at, and anything that determines the data should be shareable, bookmarkable, and survivable across a refresh. Putting them in the URL also means the browser back button undoes a filter change for free, which users expect and which you'd otherwise have to build. Server data β€” the transaction pages themselves β€” lives in a server-cache layer like React Query or SWR, keyed by the URL state. The point of this layer is that it treats server data as a cache of someone else's truth rather than as application state: it knows about staleness, refetching, deduplication, and invalidation, which raw useState plus useEffect does not. Finally, purely local concerns β€” which rows are selected, which row is expanded, the raw text sitting in the search box before it's debounced β€” stay in component state, because nothing outside the component needs them and lifting them would just cause wider re-renders. The general rule, worth saying verbatim in the interview: lift state exactly as high as something else needs it, and no higher.

Idea two: the server owns filtering, sorting, and searching. The client holds a window of maybe a few hundred loaded rows out of 100k. If the client sorted those loaded rows, the result would be simply wrong β€” the true first row under the new sort might be row 50,001, which the client has never seen. This is worth spelling out because "just sort the array" is such an easy trap, and the failure is silent: the UI looks sorted while lying. So every combination of filters, sort, and query is a distinct server query, and the client's job is to display it, cache it under that key, and paginate through it. Client-side filtering is only defensible when you can prove the full result set is loaded β€” a few hundred rows, fully fetched β€” and if you offer that optimization you must say how you know it's complete.

Idea three: real-time events never touch the DOM directly β€” they flow into the cache, and the UI re-renders from the cache. The SSE channel on the diagram delivers tx.created, tx.updated, and balance.changed events, and the handler's only job is to patch or invalidate cache entries. This keeps one source of truth on the client (the cache) and one rendering path (cache β†’ components), so live updates and refetches can't disagree about what's on screen. The alternative β€” event handlers imperatively poking at rows β€” is how you get ghost rows and duplicated entries the first time an event races a refetch.

What "the cache" actually is, precisely. Plain JavaScript objects in browser memory, inside this tab β€” not Redis, not anything server-side. TanStack Query (or Vercel's SWR library) instantiates a client that holds a map of query key β†’ {data, status, dataUpdatedAt}, and it dies when the tab closes. The name "server cache" is industry shorthand for the client-side cache of server-owned data, and the distinction it draws is about who owns the truth, not where the bytes live: server-owned data is a copy of someone else's truth, so it needs staleness, refetching, and invalidation; client-owned UI state (selection, expanded row) is the truth, so it needs none of that. "Stale-while-revalidate" itself is a caching policy borrowed from HTTP: answer instantly from cache even if stale, refetch in the background, update in place. Invalidation targets entries by hierarchical key-prefix matching β€” invalidateQueries({queryKey: ['transactions']}) marks every filter/sort/page combination under that prefix stale, while an exact key hits one entry. Crucially, invalidation marks stale rather than deleting, so the UI keeps showing the old value until the background refetch replaces it β€” that mark-don't-evict behavior is what makes it SWR.

3. D β€” Data model

What the client holds

Transaction {
  id, accountId, date,
  amount,          // integer minor units
  currency,
  merchant, category,
  status: 'pending' | 'posted',
  version          // for corrections
}
Account {
  id, name, mask,  // "…4821"
  balance, balanceAsOf
}
Page {
  items: Transaction[],
  nextCursor: string | null
}
// cache: pages keyed by
// (filters, sort, q) β†’ [Page, …]

The one-line versions

Money is integers. Pending is a state, not an anomaly. Every row is versioned because rows change after you've shown them. The cache is normalized by id so one event can patch one row. The client is a projection of server truth, never the truth itself.

Each of those decisions has a reason you should be able to give in full sentences.

Amounts are integers in minor units (cents), never floats. IEEE 754 floating point cannot represent most decimal fractions exactly β€” 0.1 + 0.2 === 0.30000000000000004 in JavaScript β€” and while a penny of drift sounds trivial, in a financial UI it means the client can display a total that disagrees with the server's, which users notice and support tickets follow. So arithmetic happens on integers and formatting happens only at the last moment, at render, with Intl.NumberFormat, which also handles currency symbols and locale separators correctly for free.

status: pending | posted is first-class in the model because the underlying financial reality works that way. When you swipe a card, the merchant places an authorization hold β€” that's the pending transaction, and its amount is often not final. A gas station may hold $100 and post $38.62; a restaurant posts with the tip added. Days later the transaction posts, possibly with a different amount, a different date, and an enriched merchant name, or the hold is voided and the transaction vanishes entirely. A design that treats transactions as immutable append-only rows will corrupt its own display the first time a correction arrives. Modeling pending explicitly means the UI can render it distinctly (typically grayed with a "Pending" chip), users learn the semantics, and the correction flow in Β§7 has somewhere to land.

Every row carries a version. Corrections and enrichments arrive asynchronously, and β€” critically β€” the events that carry them are not guaranteed to arrive in order. If the client applies whatever event arrives last, an older update can overwrite a newer one (the lost-update problem, in miniature, on the client). A monotonically increasing version per row makes the merge rule trivial and safe: apply the patch only if its version is greater than the cached one, otherwise drop it. This one field is what makes the whole real-time story in Β§6 correct rather than hopeful.

The cache is normalized by id. When a tx.updated event arrives for one transaction, the handler should update exactly one object, not hunt through page arrays or refetch a whole page for one changed merchant name. Store rows in an id-keyed map with pages holding ids (or use a cache that supports surgical updates), and single-row patches become O(1).

4. I β€” Interface

HTTP contract

GET /transactions
  ?cursor={opaque}&limit=50
  &accountIds=…&category=…
  &from=…&to=…&q=…
  &sort=date_desc
β†’ { items: Transaction[],
    nextCursor, asOf }

GET /accounts β†’ [Account]

// real-time (SSE)
event: tx.created | tx.updated
       | balance.changed
data:  { entity…, version, eventId }
Last-Event-ID: resume after drop

Component API

<TransactionsTable
  filters={filters}
  onFiltersChange={…}
  sort={sort} onSortChange={…}
  selection={sel}
  onSelectionChange={…}
/>
// internal (uncontrolled):
//   expanded row,
//   raw search text (debounced up),
//   scroll position

Why cursor pagination and not offset β€” the full argument, because this is a guaranteed follow-up. Offset pagination (?page=3&limit=50, i.e. "skip 100 rows") has two independent failure modes here. The first is correctness: this list has new rows inserted at the top continuously. Suppose the user loads page 1, and three new transactions arrive before they request page 2. Every row shifts down by three, so page 2's "skip 50" now re-serves the last three rows of the old page 1 β€” in an infinite-scroll UI those render as visible duplicates β€” and in the opposite direction (deletions, or scrolling a descending list while rows insert) rows get silently skipped and the user never sees them. The second failure is performance: OFFSET 50000 forces the database to walk and discard fifty thousand rows before returning any, so deep pages get progressively slower. Keyset (cursor) pagination fixes both at once: the server returns an opaque token encoding the last row's sort position β€” say (date, id), with id as tiebreaker since dates collide β€” and the next page is "rows after this position," an indexed O(log n) seek that's completely stable under inserts above. The costs are real but acceptable: no "jump to page 7," and the cursor is only meaningful for the exact filter-and-sort combination that issued it. Both costs are fine for an infinite-scrolling feed, and you should name them anyway β€” conceding the weaknesses of your own choice is exactly the trade-off fluency this round is scoring.

The error contract deserves a sentence before anyone asks. Errors come back typed with a machine-readable code and a retryable flag, because the client's correct behavior differs by class: 401 routes to re-authentication, 429 backs off, 5xx retries with exponential backoff and jitter, and a validation error does none of those. An untyped error string forces the client to guess, and the guess will be wrong in exactly the cases that matter.

On the component side, the interface encodes the controlled-versus-uncontrolled decision, and the reasoning is worth internalizing rather than memorizing. A controlled prop means the parent owns the state; uncontrolled means the component keeps it internal. The table is controlled on filters, sort, and selection, and uncontrolled on expansion, scroll, and raw search text β€” and each assignment follows from asking "does anything outside this component need this value?" Filters and sort: yes β€” the fetch layer needs them to build queries, and the URL needs them for shareability, so they must live above the table. Selection: yes, but only because there's a bulk-actions bar outside the table that reads it; if selection were cosmetic, it should stay internal. Expanded row: no one else cares, so keeping it internal avoids re-rendering the page when a user opens a row. The search input is the instructive case: the raw text stays local and only a 300ms-debounced value is pushed up, because pushing every keystroke into shared state would re-render the entire table and fire a network request per character. The input is effectively uncontrolled from the table's perspective while the committed query is controlled β€” a deliberate mixed-ownership design, and saying it that way signals you understand the pattern rather than just following a library convention.

5. O β€” Optimization 1: virtualization

Here's the problem stated concretely. A row in a table like this is realistically ten or so DOM nodes β€” cells, amount, category chip, icons. Thirty thousand transactions would mean roughly 300,000 DOM nodes. The browser's bottleneck isn't JavaScript at that point; it's style recalculation, layout, and paint, all of which scale with live DOM size. Scrolling a 300k-node page janks no matter how well-written your React is, and the initial render would take seconds. The fix is to stop pretending the DOM must mirror the dataset: render only what's visible.

The mechanism is simple enough to explain in four sentences, and you should, even though in practice you'd use @tanstack/react-virtual or react-window β€” explaining the mechanism is what proves you didn't just memorize a library name. With a fixed row height of, say, 44px, the row under any scroll position is pure arithmetic: startIndex = floor(scrollTop / 44). The component renders roughly the visible twenty rows plus an overscan buffer of five to ten on each side, positions them absolutely with transform: translateY(index Γ— 44), and sets the scroll container's inner height to totalCount Γ— 44 so the scrollbar is honest about the full dataset. Rows scrolled far out of view unmount. The DOM cost becomes O(window) β€” constant, around thirty rows β€” regardless of whether the dataset is ten thousand or a million rows. That sentence, "DOM cost is O(window), independent of dataset size," is the crisp claim to land.

Two supporting decisions make it work well rather than merely work. Fixed row height is what makes the position math O(1); variable heights force you to measure rows and maintain a running position cache, which is complexity you should refuse unless the design genuinely demands it (and a transactions table doesn't β€” rows are uniform). Row memoization (React.memo with stable callbacks) matters because during scroll the window's contents mostly don't change β€” only the edges mount and unmount β€” and without memoization every scroll tick re-renders all thirty visible rows for nothing. The overscan buffer, meanwhile, is a small tradeoff you can articulate: more overscan means fewer white flashes during fast scrolling but more off-screen work; five to ten rows is the conventional balance.

Now say the cost out loud, because virtualization genuinely breaks things. Browser find-in-page (Ctrl-F) can't find rows that aren't in the DOM. Screen readers can't announce a 30k-row table when only 30 rows exist. The mitigations: give the table role="grid" with aria-rowcount set to the true total and aria-rowindex on each rendered row, so assistive tech knows both the real size and each row's true position; implement keyboard navigation (arrow keys, PageUp/PageDown) that moves a focus index and scrolls the window to follow it; and accept that Ctrl-F is traded away in favor of the in-app search β€” which is part of why search needs to be good. Raising the accessibility cost yourself, unprompted, is the difference between "used a library once" and "owns this pattern" β€” and per the rubric, ignoring a11y in a data-dense UI caps the rendering pillar.

Infinite scroll integrates naturally: a sentinel element sits at the end of the rendered range with an IntersectionObserver watching it, and when it enters the overscan region the next cursor page is fetched and appended to the cache. Prefetching at around 70% scroll depth hides the fetch latency entirely for a steady scroller. Offer a visible "Load more" button as well β€” it's the accessible fallback and gives keyboard users a reachable footer.

6. O β€” Optimization 2: real-time updates without render thrash

Transport first, because the choice is defensible on requirements rather than fashion. The traffic here is strictly one-directional β€” the server tells the client about new transactions, corrections, and balance changes; the client never pushes anything upstream on this channel. That asymmetry is the argument for SSE over WebSocket: SSE is plain HTTP (so proxies, load balancers, and corporate networks treat it kindly), the browser's EventSource reconnects automatically, and β€” the underrated feature β€” it has resume built in: the browser sends the Last-Event-ID header on reconnect, and a server that keeps a short replay buffer can deliver the missed events instead of forcing a full refetch. WebSocket buys you bidirectional messaging that this feature doesn't need, and in exchange you take on custom reconnect logic, heartbeats, and resume protocol. Choosing the lighter tool and saying why is the trade-off answer. Keep a 30-second polling fallback for clients whose networks break streaming β€” and note the double duty: that poll is also the reconciliation sweep that catches anything the event stream dropped, which turns "polling fallback" from an apology into part of the correctness story.

Event-application pipeline

on event:
  1. dedupe by eventId (LRU set)
  2. version <= cached.version? drop
  3. known row β†’ patch it in cache
     new row matching current
       filter β†’ insert by sort key
  4. buffer events; flush all
     once per animation frame

Why each step exists

1 β€” at-least-once delivery means duplicates are normal, not exceptional. 2 β€” out-of-order delivery means "latest received" β‰  "newest"; version is the truth. 3 β€” patch, don't refetch: one changed row shouldn't cost a page fetch. 4 β€” a sync burst of 50 events should cost one render, not 50.

The mindset that makes this correct: treat events as hints, not as truth. The event stream is fed by webhooks from upstream bank integrations, and webhook pipelines drop messages, deliver them twice, and deliver them out of order β€” assuming otherwise is on the rubric's red-flag list verbatim. So every event passes through deduplication (by eventId) and a version gate (drop anything not newer than the cache) before it touches state, and the periodic poll reconciles whatever slipped through anyway. If the SSE connection drops and the resume gap exceeds the server's replay buffer, the client doesn't try to be clever: it invalidates page one and balances and refetches. "Reconnected" never implies "consistent" β€” you reconcile on every reconnect.

Render thrash is the other half of the problem. When a bank sync completes, events arrive in bursts β€” potentially dozens per second. Applying each one individually means dozens of cache updates and dozens of renders per second, which is jank the user can feel while scrolling. The fix is to buffer incoming events and flush the whole buffer as one batched cache update once per animation frame (~16ms). The ceiling becomes one render per frame regardless of event rate, and because rows are memoized, that render only reconciles rows that actually changed. Add a circuit breaker for pathological bursts: past a few hundred buffered events, patching row-by-row costs more than starting over, so drop the buffer and refetch the visible pages instead.

Scroll anchoring β€” a detail interviewers at data-product companies specifically love. If the user has scrolled down into last month and new transactions arrive at the top, inserting them immediately would shift every row down and yank the content out from under the user's eyes β€” the feed equivalent of layout shift. The standard fix, familiar from Twitter and Slack: when scrolled away from the top, don't insert; instead show a pinned pill β€” "3 new transactions ↑" β€” and merge the buffered rows only when the user clicks it or scrolls back to the top. When the user is already at the top, merge live. New data must never move content the user is currently reading.

7. O β€” Optimization 3: corrections and late arrivals

This section is the fintech-specific heart of the question, and handling it well is what separates a generic "infinite list" answer from a domain-aware one. Three things happen to financial data after the client has already rendered it, and the design has to be honest about each.

Pending transactions mutate. The $100 gas-station hold posts as $38.62. The restaurant charge posts a day later with the tip added. The merchant string "SQ *COFFEE 4821" gets enriched into "Blue Bottle Coffee" with a category. In each case the row the user already saw changes amount, date, merchant, or all three. The client handles this as a patch-in-place by id, gated by version, with a brief highlight animation on the changed row. The animation is not decoration β€” silently mutating displayed financial data erodes exactly the trust this product depends on, so the UI acknowledges the change visibly.

Transactions arrive late, into the middle of loaded data. A transaction dated three days ago can arrive today (slow merchant settlement, delayed sync), and its sorted position falls inside a page range the user already loaded β€” the "pages are stable" assumption breaks. There are two honest strategies. Locally insert the row if its sort position lands within the loaded window β€” cheap, instant, and correct for what's visible. Or mark the affected page range stale and refetch it β€” always correct, but heavier. The right answer combines them: insert locally when the row belongs in or near the visible window so the user sees it now, and mark deeper cached pages stale so they're refetched on next approach rather than eagerly. And if a pending transaction is voided, the corresponding event removes the row β€” deletions are part of the same patch pipeline, not a special case.

The hardest case: a correction changes the sort key itself. When sorted by date and an event updates a row's date (the classic being pending→posted, where the settle date replaces the authorization date), patching in place would leave the row sitting in the wrong position — the client's pages are a mirror of a server-side ordering, and the mirror is now wrong. The honest mental model is that a sort-key change is not an update at all: it's a remove plus insert. Handle it in three cases. If the new position falls within the loaded window, remove the row from its old spot and binary-search its new spot by sort key — cheap and correct. If the new position falls outside what's loaded, remove the row and don't insert it anywhere; it will appear when the user scrolls to that region and fresh pages are fetched. And if the change is bulk or ambiguous (a sync rewrote many dates), stop being surgical — invalidate the transactions prefix and refetch page one, the same circuit-breaker logic as the event-burst case. Layer a UX rule on top: a row that's currently on screen must not teleport mid-glance — animate it to its new position so the eye can follow, or badge it, while off-screen rows move silently. And concede the limitation out loud: after local reinsertion the view is best-effort until the next server fetch, because a correction can also move a row into your filtered range and no event you're holding tells you that reliably — the 30s poll and refetch-on-scroll are what reconcile it. Naming that gap reads as maturity, not weakness.

Display pending charges in their own pinned section β€” it's a UX decision that quietly solves an engineering problem. Real banking UIs (Chase, Amex, Apple Card) pin a "Pending" section above the posted history, and the reasons stack. It matches the user's mental model: pending charges are the "what's in flight" set, checked differently from history. It's honest labeling: visually separating non-final amounts stops users from reconciling against numbers that will change. And structurally, it defuses most of the sort-key problem above β€” the most common sort-key change is exactly pendingβ†’posted, and if pending rows live in their own section, that transition becomes a designed move between two containers instead of a row teleporting within one sorted list. The costs to name: the pinned section breaks pure chronology, so apply it only in the default date-sorted view β€” when the user explicitly sorts by amount or filters, pendings go inline with a "Pending" chip, because an explicit sort is the user saying "order everything by this." Pending is also naturally small (days of in-flight charges, maybe 5–20 rows), so the section never needs virtualization or pagination of its own.

Balances are never computed client-side. It's tempting: the rows are right there, why not sum them into the header? Because the client's window is partial, pending amounts aren't final, and holds, foreign-exchange adjustments, and interest all live server-side β€” a client-computed balance will drift from truth, and a wrong balance is the single worst number to show in a fintech UI. The balance header refetches on balance.changed and displays the server's number with its as-of timestamp. Server-authoritative, always, with staleness shown honestly rather than hidden.

8. O β€” Optimization 4: security, resilience, observability

These are the two pillars where the rubric says a weak answer is an automatic fail, so this section is not garnish β€” budget real interview minutes for it.

Security and privacy. Tokens never live in localStorage: anything readable by JavaScript is readable by any injected script, so one XSS anywhere in the page means exfiltrated bank tokens. Instead, authentication rides in short-lived, narrowly-scoped tokens delivered as HttpOnly, SameSite cookies via the BFF β€” the browser attaches them automatically and script cannot touch them, which converts token theft from "one XSS" into "full session hijack," a much higher bar. On screen, account numbers render masked (…4821) with an explicit reveal toggle that re-masks on blur. And analytics deserves its own discipline, because it's the leak nobody notices: telemetry captures event names, ids, timings, and error codes β€” never amounts, merchant names, or balances β€” and error reporters are configured to scrub request and response bodies, because a crash log with a bank balance in it is a compliance incident, not a debugging aid. Finally, least-privilege UI: capabilities a role lacks (say, export) are hidden or disabled client-side and enforced server-side β€” the client-side part is UX, the server-side part is the actual security boundary, and you should say both halves.

Resilience. Every view in this design has five states, not two: loading (skeleton rows, sized to prevent layout shift), error (with a retry action, never a dead end), empty ("no transactions match these filters" β€” with a clear-filters shortcut, since a filtered-to-nothing state is usually user intent gone wrong), permission-denied (a bank link expired β†’ a "reconnect your account" call-to-action, which in a Plaid-style product is a routine state, not an error), and stale ("as of 2:14 PM β€” reconnecting…" when the live channel is down). Enumerating the matrix unprompted matters because the missing states are the rubric's red flag. Beyond states: network retries use exponential backoff with jitter; and there's a race guard on filter changes β€” when a user changes filters twice quickly, the slow first response must not overwrite the fast second one, so in-flight requests for abandoned query keys are cancelled via AbortController (keyed caches make this nearly automatic, but name the race anyway β€” it's the kind of correctness detail this round exists to probe). An error boundary wraps the table so a rendering bug in one row degrades to a retry panel instead of white-screening the page.

Observability. Real-user monitoring tracks time-to-first-row, scroll jank via long-task counts, SSE disconnect rates, and fetch failure rates by endpoint; every request carries a correlation ID so a user-reported "my transactions won't load" traces end-to-end through the BFF to the upstream; and there are alerts on financial-data fetch failures specifically β€” per the rubric, having no telemetry on financial failure modes is a red flag, and the practical version of that sentence is "if transactions fail to load for 1% of users, I want a page, not a support ticket."

9. Follow-ups β€” answers to have ready

Why not filter and sort client-side? You already have pages cached.

Because the client holds a window, not the dataset. Sorting three loaded pages of a 100k-row set produces an ordering that's simply wrong β€” the true first row under the new sort may be one the client has never fetched. Worse, it fails silently: the list looks sorted. Server-side filter/sort with the combination as the cache key is the only honest approach at this scale. The narrow exception: when the full result set is provably loaded (a few hundred rows, fetched to completion), client-side is a fine instant-feel optimization β€” but you must be able to say how you know it's complete.

User is deep in the list and changes the sort β€” what happens to their cursor and scroll position?

A cursor encodes a position in one specific ordering, so it's meaningless under a new sort β€” new sort means new query key, fresh fetch from the top, scroll reset to top deliberately. Trying to preserve scroll position across a re-ordering is preserving a number whose meaning changed. What you can offer is continuity of a different kind: keep the previous result on screen (stale-while-revalidate) until the first new page lands, so the transition doesn't flash empty.

The SSE connection drops for five minutes. Walk through recovery.

EventSource reconnects on its own, presenting Last-Event-ID. If the server's replay buffer still covers that gap, it replays the missed events, which flow through the normal dedupe→version→batch pipeline — recovery is invisible. If the gap exceeds retention, replay is impossible, so the client falls back to invalidate-and-refetch of page one plus balances. Meanwhile the 30s poll has been bounding staleness the whole time, and the stale banner ("as of 2:14 PM") kept the user informed. The principle to state: reconnection never implies consistency — you reconcile on every reconnect, cheaply if replay covers you, fully if it doesn't.

Search fires a request per keystroke?

No β€” the raw text stays local to the input, and a 300ms-debounced value drives the query. On each new committed query, the in-flight previous request is aborted via AbortController, which kills both the wasted work and the out-of-order-response race (a slow "gro" response landing after the "grocery" response and overwriting it). Results cache per query string, so backspacing to a previous query is instant. Four moves β€” debounce, cancel, race-guard, cache β€” and naming all four is the complete answer.

A burst of 100 events/sec arrives during a bank sync. Does the UI melt?

No, because events buffer and flush once per animation frame as a single cache update β€” the render ceiling is one per frame (~60/sec) regardless of event rate, and memoized rows mean each render only reconciles changed rows. Past a threshold (~500 buffered), the client stops patching and refetches visible pages instead, because at that point starting over is cheaper than 500 surgical updates. Off-screen changes cost nothing until scrolled to; on-screen ones highlight briefly.

"Select all" with 100k matching rows β€” what does that mean?

Not 100k ids in client memory. Visible selections are an id Set, but "select all matching" becomes a server-side predicate: the bulk operation ships the current filter plus a list of explicitly excluded ids ("everything matching, except these three"). This also makes the semantics honest about rows the client has never seen. Bulk re-categorize β€” the one write in this design β€” carries a client-generated idempotency key so a timeout retry can't double-apply, and since re-categorization is reversible, optimistic UI with rollback on error is acceptable here. Contrast it aloud with money movement, where optimistic UI is forbidden β€” that contrast is rubric gold.

What actually breaks at 1M rows?

Not the DOM β€” the window is constant size by construction. What strains: the client cache accumulates hundreds of pages (cap retained pages and refetch on scroll-back), and the spacer-height trick hits browser maximum-element-height limits (~33M px, roughly 750k 44px rows β€” switch to segmented spacers). But the best answer is product-shaped: nobody scrolls a million rows. Past a point, scrolling is the wrong navigation primitive, and the design should be pushing users toward search and filters β€” which this design has been optimizing all along.

10. Numbers to drop

Render budget

60fps means 16ms per frame. With ~30 memoized rows in the DOM, scroll work is transform-only β€” compositor-thread work, no layout or paint on the main thread. "DOM cost is O(window), independent of dataset size" is the sentence that shows you understand why it's fast, not just that it is. A 50-row page at ~300 bytes per transaction is ~15KB β€” cheap enough to prefetch the next page at 70% scroll depth.

Freshness budget

SSE delivers sub-second when healthy; the 30s poll bounds staleness when it isn't; the as-of stamp makes whatever staleness exists honest. Search debounce is 300ms; with a ~400ms p95 server response, perceived filter-change latency lands ~700ms with skeletons β€” inside the "feels responsive" envelope for a data view, and you've named every term in the sum.

11. Rubric self-check (FullStack-SysDesign-Rubric)

PillarWhere it's covered
1 Product framingΒ§1 β€” scope, non-goals, the three driving constraints, the core tension named up front
2 Data/API contractΒ§3–4 β€” cursor-vs-offset argued in full, typed errors, versioned rows, pending/posted, SSE resume
3 RenderingΒ§5 β€” virtualization mechanism explained, memoization, fixed heights, a11y costs raised unprompted
4 State/caching/syncΒ§2, Β§4, Β§6 β€” three-layer state, keyed queries, dedupe/version/batch pipeline, race guards
5 Security/privacyΒ§8 β€” HttpOnly token model, masking, analytics scrubbing, least-privilege both halves
6 Reliability/correctnessΒ§7–8 β€” five-state matrix, corrections, reconciliation on reconnect, poll as safety net
7 Perf/observabilityΒ§8, Β§10 β€” quantified budgets, RUM, correlation IDs, alerts on financial fetch failures

12. 30-second recap script

The dataset is huge and always changing, so the client owns a window, never the truth. Filters, sort, and search live in the URL and key a server-cache query; the server does all filtering and returns keyset-cursor pages, because offset pagination drifts and duplicates under top-inserts. The table virtualizes to about thirty memoized DOM rows with ARIA row counts so accessibility survives, and infinite-scrolls through a sentinel. Real-time arrives over SSE with Last-Event-ID resume; events are deduped, version-gated, buffered per frame, and patch the cache β€” and new rows above the fold become a "new transactions" pill so content never shifts under the reader. Pending-to-posted corrections patch in place by id and version with a visible highlight, balances are server-authoritative with an as-of stamp, and a 30-second poll is the reconciliation safety net for everything the stream drops. Tokens sit in HttpOnly cookies, PII stays masked and out of analytics, every view has loading, error, empty, permission-denied, and stale states, and RUM alerts fire when financial fetches fail.